#!/bin/sh
#-------------------------------------------------------------------------+
# Copyright (C) 2021 Matt Churchyard (churchers@gmail.com)
# All rights reserved
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted providing that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

# vm migrate ...
#
# @param string name the guest to send
# @param string host host to send guest to
#
migration::run(){
    local _name
    local _ds="default"
    local _start="1"
    local _renaming="0"
    local _config _opt _stage _inc _triple _rdataset _pid _exists _rname _running
    local _snap1 _snap2 _snap3 _destroy
    local _count=0

    while getopts cn12txr:d:i: _opt; do
        case $_opt in
            c) _config="1" ;;
            r) _rname="${OPTARG}" ;;
            n) _start="" ;;
            i) _inc="${OPTARG}" ;;
            1) _stage="1" ;;
            2) _stage="2" ;;
            t) _triple="1" ;;
            x) _destroy="1" ;;
            d) _ds="${OPTARG}" ;;
        esac
    done

    # get the name and host
    shift $((OPTIND -1))
    _name="$1"
    _host="$2"

    # do we want to output our config?
    # sender uses the config option to pull config from the recieve end
    if [ -n "${_config}" ]; then
        migration::__check_config "${_ds}"
        exit
    fi

    # basic checks
    [ -z "${_name}" -o -z "${_host}" ] && util::usage
    datastore::get_guest "${_name}" || util:err "unable to locate guest - '${_name}'"
    [ -z "${VM_DS_ZFS}" ] && util:err "the source datastore must be ZFS to support migration"
    [ -n "${_stage}" -a -n "${_triple}" ] && util::err "single stage and triple stage are mutually exclusive"
    [ "${_stage}" = "2" -a -z "${_inc}" ] && util::err "source snapshot must be given when running stage 2"

    if [ -n "${_rname}" ]; then
        util::check_name "${_rname}" || util::err "invalid virtual machine name - '${_rname}'"
        _renaming="1"
    else
        _rname="${_name}"
    fi

    # check guest can be sent
    config::load "${VM_DS_PATH}/${_name}/${_name}.conf"
    migration::__check_compat

    # check running state
    vm::confirm_stopped "${_name}" "1" >/dev/null 2>&1
    _state=$?
    [ ${_state} -eq 2 ] && util::err "guest is powered up on another host"
    [ ${_state} -eq 1 ] && _running="1"

    # try to get pid
    if [ -n "${_running}" ]; then
        _pid=$(pgrep -fx "bhyve: ${_name}")
        [ -z "${_pid}" ] && util::err "guest seems to be running but can't find its pid"
    fi

    # try to get remote config
    _rdataset=$(ssh "${_host}" vm migrate -cd "${_ds}" 2>/dev/null)
    [ $? = "1" -o -z "${_rdataset}" ] && util::err "unable to get config from ${_host}"

    echo "Attempting to send ${_name} to ${_host}"
    echo "  * remote dataset ${_rdataset}/${_rname}"
    [ -n "${_running}" ] && echo "  * source guest is powered on (#${_pid})"

    # STAGE 1
    # we send a full snapshot of the guest
    if [ -z "${_stage}" -o "${_stage}" = "1" ]; then
        _snap1="$(date +'%Y%m%d%H%M%S-s1')"
        echo "  * stage 1: taking snapshot ${_snap1}"
        zfs snapshot -r "${VM_DS_ZFS_DATASET}/${_name}@${_snap1}" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err_inline "error taking local snapshot"

        # send this snapshot
        migrate::__send "1" "${_snap1}" "${_inc}"
    fi

    # STAGE 1B
    # do it again in triple mode
    # for a big guest, hopefully not too much changed during full send
    # this will therefore complete fairly quick, leaving very few changes for stage 2
    if [ -n "${_triple}" ]; then
        _snap2="$(date +'%Y%m%d%H%M%S-s1b')"
        echo "  * stage 1b: taking snapshot ${_snap2}"
        zfs snapshot -r "${VM_DS_ZFS_DATASET}/${_name}@${_snap2}" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err_inline "error taking local snapshot"

        # send this snapshot
        migrate::__send "1b" "${_snap2}" "${_snap1}"
    fi

    # only running first stage
    if [ "${_stage}" = "1" ]; then
        echo "  * done"
        exit
    fi

    # if it's running we now need to stop it
    if [ -n "${_running}" ]; then
        echo -n "  * stage 2: attempting to stop guest"

        kill ${_pid} >/dev/null 2>&1

        while [ ${_count} -lt 60 ]; do
            sleep 2
            kill -0 ${_pid} >/dev/null 2>&1 || break
            echo -n "."
            _count=$((_count + 1))
        done

        echo ""
    fi

    # has it stopped?
    kill -0 ${_pid} >/dev/null 2>&1 && util:err_inline "failed to stop guest"
    echo "  * stage 2: guest powered off"

    # only needed if running or specifically doing a stage 2
    if [ -n "${_running}" -o "${_stage}" = "2" ]; then
        _snap3="$(date +'%Y%m%d%H%M%S-s2')"
        echo "  * stage 2: taking snapshot ${_snap3}"
        zfs snapshot -r "${VM_DS_ZFS_DATASET}/${_name}@${_snap3}" >/dev/null 2>&1
        [ $? -eq 0 ] || util::err_inline "error taking local snapshot"

        # send this snapshot
        if [ "${_triple}" = "1" ]; then
            migrate::__send "2" "${_snap3}" "${_snap2}"
        elif [ "${_stage}" = "2" ]; then
            migrate::__send "2" "${_snap3}" "${_inc}"
        else
            migrate::__send "2" "${_snap3}" "${_snap1}"
        fi
    fi

    # do we need to rename?
    [ "${_renaming}" = "1" ] && migrate::__rename_config

    # start
    if [ -n "${_start}" -a -n "${_running}" ]; then
        echo "  * attempting to start ${_rname} on ${_host}"
        ssh ${_host} vm start ${_rname}
    fi

    if [ -n "${_destroy}" ]; then
        echo "  * removing source guest"
        zfs destroy -r "${VM_DS_ZFS_DATASET}/${_name}"
    else
        echo "  * removing snapshots"
        [ -n "${_snap1}" ] && zfs destroy "${VM_DS_ZFS_DATASET}/${_name}@${_snap1}" >/dev/null 2>&1
        [ -n "${_snap2}" ] && zfs destroy "${VM_DS_ZFS_DATASET}/${_name}@${_snap2}" >/dev/null 2>&1
        [ -n "${_snap3}" ] && zfs destroy "${VM_DS_ZFS_DATASET}/${_name}@${_snap3}" >/dev/null 2>&1
    fi

    echo "  * done"
}

# updates the config file for a renamed guest
# god knows why I didn't just use "guest.conf"
#
migrate::__rename_config(){
    local _path

    # we need the mount path first
    _path=$(ssh "${_host}" mount | grep "^${_rdataset} " | cut -wf3)

    if [ $? -ne 0 -o -z "${_path}" ]; then
        echo "  ! failed to find remote datastore path. guest may not start"
        return 1
    fi

    # make sure it's mounted on remote
    ssh "${_host}" zfs mount "${_rdataset}/${_rname}" >/dev/null 2>&1

    echo "  * renaming configuration file to ${_rname}.conf"
    ssh "${_host}" mv "${_path}/${_rname}/${_name}.conf" "${_path}/${_rname}/${_rname}.conf" >/dev/null 2>1

    if [ $? -ne 0 ]; then
        echo "  ! failed to find rename remote configuration file. guest may not start"
        return 1
    fi
}

migrate::__send(){
    local _stage="$1"
    local _snap="$2"
    local _inc="$3"

    # are we sending incremental?
    if [ -n "${_inc}" ]; then
        echo "  * stage ${_stage}: sending ${VM_DS_ZFS_DATASET}/${_name}@${_snap} (incremental source ${_inc})"
        zfs send -Ri "${_inc}" "${VM_DS_ZFS_DATASET}/${_name}@${_snap}" | ssh ${_host} zfs recv "${_rdataset}/${_rname}"
    else
        echo "  * stage ${_stage}: sending ${VM_DS_ZFS_DATASET}/${_name}@${_snap}"
        zfs send -R "${VM_DS_ZFS_DATASET}/${_name}@${_snap}" | ssh ${_host} zfs recv "${_rdataset}/${_rname}"
    fi

    [ $? -eq 0 ] || util::err_inline "error detected while sending snapshot"
    echo "  * stage ${_stage}: snapshot sent"
}

# currently just outputs zfs path or error if datastore isn't zfs
# in future may also return some data we can use to verify compat, etc
#
# @param string _ds the datastore to get details of
#
migration::__check_config(){
    local _ds="$1"

    datastore::get "${_ds}"
    [ -z "${VM_DS_ZFS}" ] && exit 1

    # output the datastore dataset
    # sender needs this to do a zfs recv
    echo "${VM_DS_ZFS_DATASET}"
}

# see if a guest can be migrated.
# there are a few guest settings that are likely to
# cause the guest to break if it's moved to another host
#
migration::__check_compat(){
    local _setting _err _num=0

    # check pass through
    config::get "_setting" "passthru0"
    [ -n "${_setting}" ] && _err="pci pass-through enabled"

    # check for custom disks
    # file/zvol are under guest dataset and should go across ok
    # custom disks could be anywhere
    while true; do
        config::get "_setting" "disk${_num}_type"
        [ -z "${_setting}" ] && break
        [ "${_setting}" = "custom" ] && _err="custom disk(s) configured" && break
        _num=$((_num + 1))
    done

    [ -n "${_err}" ] && util::err "migration is not supported for this guest (${_err})"
}
